import { getEnabledElement, eventTarget } from '@cornerstonejs/core';
import type { Types } from '@cornerstonejs/core';
import { vec3, vec2 } from 'gl-matrix';
import { Events, ToolModes, StrategyCallbacks } from '../../enums';

import type {
  PublicToolProps,
  ToolProps,
  EventTypes,
  SVGDrawingHelper,
  LabelmapToolOperationDataVolume,
  LabelmapToolOperationDataStack,
} from '../../types';
import {
  fillInsideSphere,
  thresholdInsideSphere,
  thresholdInsideSphereIsland,
} from './strategies/fillSphere';
import { eraseInsideSphere } from './strategies/eraseSphere';
import {
  thresholdInsideCircle,
  fillInsideCircle,
} from './strategies/fillCircle';
import { eraseInsideCircle } from './strategies/eraseCircle';
import {
  resetElementCursor,
  hideElementCursor,
} from '../../cursors/elementCursor';

import triggerAnnotationRenderForViewportUIDs from '../../utilities/triggerAnnotationRenderForViewportIds';
import LabelmapBaseTool from './LabelmapBaseTool';
import { getStrategyData } from './strategies/utils/getStrategyData';

/**
 * @public
 */
class BrushTool extends LabelmapBaseTool {
  static toolName;
  // Remember the last drag position in both canvas and world space so we can
  // pass a full stroke segment to the strategies instead of a single point.
  private _lastDragInfo: {
    canvas: Types.Point2;
    world: Types.Point3;
  } | null = null;

  constructor(
    toolProps: PublicToolProps = {},
    defaultToolProps: ToolProps = {
      supportedInteractionTypes: ['Mouse', 'Touch'],
      configuration: {
        strategies: {
          /** Perform fill of the active segment index inside a (2d) circle */
          FILL_INSIDE_CIRCLE: fillInsideCircle,
          /** Erase (to 0) inside a circle */
          ERASE_INSIDE_CIRCLE: eraseInsideCircle,
          /** Fill a 3d sphere with the active segment index */
          FILL_INSIDE_SPHERE: fillInsideSphere,
          /** Erase inside a 3d sphere, clearing any segment index (to 0) */
          ERASE_INSIDE_SPHERE: eraseInsideSphere,
          /**
           * Threshold inside a circle, either with a dynamic threshold value
           * based on the voxels in a 2d plane around the center click.
           * Performs island removal.
           */
          THRESHOLD_INSIDE_CIRCLE: thresholdInsideCircle,
          /**
           * Threshold inside a sphere, either dynamic or pre-configured.
           * For dynamic, base the threshold on a 2d CIRCLE around the center click.
           * Do not perform island removal (this may be slow)
           * Users may see delays dragging the sphere for large radius values and
           * for complex mixtures of texture.
           */
          THRESHOLD_INSIDE_SPHERE: thresholdInsideSphere,
          /**
           * Threshold inside a sphere, but also include island removal.
           * The current implementation of this is fairly fast now, but users may
           * see delays when island removal occurs on large sections of the volume.
           */
          THRESHOLD_INSIDE_SPHERE_WITH_ISLAND_REMOVAL:
            thresholdInsideSphereIsland,
        },
        defaultStrategy: 'FILL_INSIDE_CIRCLE',
        activeStrategy: 'FILL_INSIDE_CIRCLE',
        brushSize: 25,
        useCenterSegmentIndex: false,
        preview: {
          // Have to enable the preview to use this
          enabled: false,
          previewColors: {
            0: [255, 255, 255, 128],
          },
          // The time before showing a preview
          previewTimeMs: 250,
          // The distance to move to show a preview before preview time expired
          previewMoveDistance: 8,
          // The distance to drag before being considered a drag rather than click
          dragMoveDistance: 4,
          // The time to consider a mouse click a drag when moved less than dragMoveDistance
          dragTimeMs: 500,
        },
        actions: {
          [StrategyCallbacks.AcceptPreview]: {
            method: StrategyCallbacks.AcceptPreview,
            bindings: [
              {
                key: 'Enter',
              },
            ],
          },
          [StrategyCallbacks.RejectPreview]: {
            method: StrategyCallbacks.RejectPreview,
            bindings: [
              {
                key: 'Escape',
              },
            ],
          },
          /**
           * Pressing the i key will interpolate between labelmap slices,
           * without distance or extent extrapolation.  This has to be used
           * on overlapping segments.  That is, for two slices (axial, sagittal or coronal),
           * with intervening slices, where the segments occupy the same area
           * of the image, an interpolation between the two segments will
           * created on intervening slices.
           */
          [StrategyCallbacks.Interpolate]: {
            method: StrategyCallbacks.Interpolate,
            bindings: [
              {
                key: 'i',
              },
            ],
            configuration: {
              useBallStructuringElement: true,
              // noHeuristicAlignment: true,
              noUseDistanceTransform: true,
              noUseExtrapolation: true,
            },
          },
          /**
           * Pressing the e key will interpolate with the full set of defaults,
           * that includes some distance/extent extrapolation, so can interpolate
           * between SLIGHTLY non-overlapping labelmaps.  That is, if you have
           * segments on two slices, and the slices occupy almost the same
           * are on the two different slices, then the slices in between will
           * be interpolated.  If the position is quite different, it may create
           * an interpolation, but it won't be a very useful one as it will be
           * two separate interpolations going to an empty space.
           */
          interpolateExtrapolation: {
            method: StrategyCallbacks.Interpolate,
            bindings: [
              {
                key: 'e',
              },
            ],
            // Morphological interpolation config
            configuration: {},
          },
        },
      },
    }
  ) {
    super(toolProps, defaultToolProps);
  }

  onSetToolPassive = (evt) => {
    this.disableCursor();
  };

  onSetToolEnabled = () => {
    this.disableCursor();
  };

  onSetToolDisabled = (evt) => {
    this.disableCursor();
  };

  private disableCursor() {
    this._hoverData = undefined;
    this.rejectPreview();
  }

  preMouseDownCallback = (
    evt: EventTypes.MouseDownActivateEventType
  ): boolean => {
    const eventData = evt.detail;
    const { element, currentPoints } = eventData;
    const enabledElement = getEnabledElement(element);
    const { viewport } = enabledElement;

    // @ts-expect-error
    this._editData = this.createEditData(element);
    this._activateDraw(element);

    hideElementCursor(element);

    evt.preventDefault();

    // This might be a mouse down
    this._previewData.isDrag = false;
    this._previewData.timerStart = Date.now();
    const canvasPoint = vec2.clone(currentPoints.canvas) as Types.Point2;
    const worldPoint = viewport.canvasToWorld([
      canvasPoint[0],
      canvasPoint[1],
    ]) as Types.Point3;
    this._lastDragInfo = {
      canvas: canvasPoint,
      world: vec3.clone(worldPoint) as Types.Point3,
    };

    const hoverData = this._hoverData || this.createHoverData(element);

    triggerAnnotationRenderForViewportUIDs(hoverData.viewportIdsToRender);

    const operationData = this.getOperationData(element);
    if (!operationData) {
      return false;
    }

    this.applyActiveStrategyCallback(
      enabledElement,
      operationData,
      StrategyCallbacks.OnInteractionStart
    );

    return true;
  };

  /**
   * This call will be made when the mouse moves and the tool is active, but
   * not actually drawing at the moment.
   * The behavior is:
   *    1. Update the cursor
   *    2. Call the active strategy event 'preview' and 'rejectPreview'
   *       on the mouse cursor position on a periodic basis to create a preview
   *       when configured to do so.
   *
   * The preview will be shown after the mouse has been stationary for 250 ms.
   * Any preview will be cancelled (immediately) after moving outside the center
   * distance.
   * As well, if the mouse moves but stays inside the center area for 250 ms,
   * then the cancel will happen with a new preview being added.
   *
   * See mouse up details for how the preview gets accepted.
   *
   * The preview also needs to be cancelled on changing tools.
   */
  mouseMoveCallback = (evt: EventTypes.InteractionEventType): void => {
    if (!this.isPrimary) {
      return;
    }
    if (this.mode === ToolModes.Active) {
      this.updateCursor(evt);
      if (!this.configuration.preview.enabled) {
        return;
      }
      const { previewTimeMs, previewMoveDistance, dragMoveDistance } =
        this.configuration.preview;
      const { currentPoints, element } = evt.detail;
      const { canvas } = currentPoints;

      const { startPoint, timer, timerStart, isDrag } = this._previewData;

      if (isDrag) {
        return;
      }

      const delta = vec2.distance(canvas, startPoint);
      const time = Date.now() - timerStart;
      if (
        delta > previewMoveDistance ||
        (time > previewTimeMs && delta > dragMoveDistance)
      ) {
        if (timer) {
          window.clearTimeout(timer);
          this._previewData.timer = null;
        }
        if (!isDrag) {
          this.rejectPreview(element);
        }
      }
      if (!this._previewData.timer) {
        const timer = window.setTimeout(this.previewCallback, 250);
        Object.assign(this._previewData, {
          timerStart: Date.now(),
          timer,
          startPoint: canvas,
          element,
        });
      }
    }
  };

  previewCallback = () => {
    // Don't run the preview callback if we're currently dragging
    if (this._previewData.isDrag) {
      this._previewData.timer = null;
      return;
    }

    this._previewData.timer = null;

    // get strategy data
    const operationData = this.getOperationData(this._previewData.element) as
      | LabelmapToolOperationDataStack
      | LabelmapToolOperationDataVolume;
    const enabledElement = getEnabledElement(this._previewData.element);
    if (!enabledElement) {
      return;
    }

    const { viewport } = enabledElement;
    // active strategy
    const activeStrategy = this.configuration.activeStrategy;
    const strategyData = getStrategyData({
      operationData,
      viewport,
      strategy: activeStrategy,
    });

    if (!operationData) {
      return;
    }

    const memo = this.createMemo(
      operationData.segmentationId,
      strategyData.segmentationVoxelManager
    );

    this._previewData.preview = this.applyActiveStrategyCallback(
      getEnabledElement(this._previewData.element),
      {
        ...operationData,
        ...strategyData,
        memo,
      },
      StrategyCallbacks.Preview
    );
  };

  /**
   * Updates the cursor position and whether it is showing or not.
   * Can be over-ridden to add more cursor details or a preview.
   */
  protected updateCursor(evt: EventTypes.InteractionEventType) {
    const eventData = evt.detail;
    const { element } = eventData;
    const { currentPoints } = eventData;
    const centerCanvas = currentPoints.canvas;
    this._hoverData = this.createHoverData(element, centerCanvas);

    this._calculateCursor(element, centerCanvas);

    if (!this._hoverData) {
      return;
    }

    BrushTool.activeCursorTool = this;
    triggerAnnotationRenderForViewportUIDs(this._hoverData.viewportIdsToRender);
  }

  private _dragCallback = (evt: EventTypes.InteractionEventType): void => {
    const eventData = evt.detail;
    const { element, currentPoints } = eventData;
    const enabledElement = getEnabledElement(element);
    const { viewport } = enabledElement;

    this.updateCursor(evt);

    const { viewportIdsToRender } = this._hoverData;

    triggerAnnotationRenderForViewportUIDs(viewportIdsToRender);

    const delta = vec2.distance(
      currentPoints.canvas,
      this._previewData.startPoint
    );
    const { dragTimeMs, dragMoveDistance } = this.configuration.preview;
    if (
      !this._previewData.isDrag &&
      Date.now() - this._previewData.timerStart < dragTimeMs &&
      delta < dragMoveDistance
    ) {
      // If we are showing a preview, then don't start dragging quite immediately
      // so that click up can accept the preview.
      return;
    }

    // Clear any pending preview timer when dragging starts
    if (this._previewData.timer) {
      window.clearTimeout(this._previewData.timer);
      this._previewData.timer = null;
    }
    if (!this._lastDragInfo) {
      const startCanvas = this._previewData.startPoint;
      const startWorld = viewport.canvasToWorld([
        startCanvas[0],
        startCanvas[1],
      ]) as Types.Point3;
      this._lastDragInfo = {
        canvas: vec2.clone(startCanvas) as Types.Point2,
        world: vec3.clone(startWorld) as Types.Point3,
      };
    }

    const currentCanvas = currentPoints.canvas;
    const currentWorld = viewport.canvasToWorld([
      currentCanvas[0],
      currentCanvas[1],
    ]) as Types.Point3;

    this._hoverData = this.createHoverData(element, currentCanvas);

    this._calculateCursor(element, currentCanvas);

    const operationData = this.getOperationData(element);
    if (!operationData) {
      return;
    }
    // Hand the strategy the exact stroke segment we just traversed so it can
    // paint a continuous capsule in one pass instead of trying to infer the
    // path from scattered samples.
    operationData.strokePointsWorld = [
      vec3.clone(this._lastDragInfo.world) as Types.Point3,
      vec3.clone(currentWorld) as Types.Point3,
    ];

    this._previewData.preview = this.applyActiveStrategy(
      enabledElement,
      operationData
    );

    const currentCanvasClone = vec2.clone(currentCanvas) as Types.Point2;
    this._lastDragInfo = {
      canvas: currentCanvasClone,
      world: vec3.clone(currentWorld) as Types.Point3,
    };
    this._previewData.element = element;
    // Add a bit of time to the timer start so small accidental movements dont
    // cause issues on clicking
    this._previewData.timerStart = Date.now() + dragTimeMs;
    this._previewData.isDrag = true;
    this._previewData.startPoint = currentCanvasClone;
  };

  private _calculateCursor(element, centerCanvas) {
    const enabledElement = getEnabledElement(element);

    this.applyActiveStrategyCallback(
      enabledElement,
      this.getOperationData(element),
      StrategyCallbacks.CalculateCursorGeometry
    );
  }

  /**
   * The end callback call is made when the mouse is released.  This will
   * perform another active strategy render event to render the final position.
   * As well, the finish strategy callback will be made during this time.
   */
  private _endCallback = (evt: EventTypes.InteractionEventType): void => {
    const eventData = evt.detail;
    const { element } = eventData;
    const enabledElement = getEnabledElement(element);

    const operationData = this.getOperationData(element);
    if (!operationData) {
      return;
    }
    // Don't re-fill when the preview is showing and the user clicks again
    // otherwise the new area of hover may get filled, which is unexpected
    if (!this._previewData.preview && !this._previewData.isDrag) {
      this.applyActiveStrategy(enabledElement, operationData);
    }

    this.doneEditMemo();

    this._deactivateDraw(element);

    resetElementCursor(element);

    this.updateCursor(evt);

    this._editData = null;

    this._lastDragInfo = null;

    this.applyActiveStrategyCallback(
      enabledElement,
      operationData,
      StrategyCallbacks.OnInteractionEnd
    );

    if (!this._previewData.isDrag) {
      this.acceptPreview(element);
    }
  };

  public getStatistics(element, segmentIndices?) {
    if (!element) {
      return;
    }
    const enabledElement = getEnabledElement(element);
    const stats = this.applyActiveStrategyCallback(
      enabledElement,
      this.getOperationData(element),
      StrategyCallbacks.GetStatistics,
      segmentIndices
    );

    return stats;
  }

  /**
   * Cancels any preview view being shown, resetting any segments being shown.
   */
  public rejectPreview(element = this._previewData.element) {
    if (!element) {
      return;
    }

    this.doneEditMemo();

    const enabledElement = getEnabledElement(element);
    if (!enabledElement) {
      return;
    }
    this.applyActiveStrategyCallback(
      enabledElement,
      this.getOperationData(element),
      StrategyCallbacks.RejectPreview
    );

    this._previewData.preview = null;
    this._previewData.isDrag = false;
  }

  /**
   * Accepts a preview, marking it as the active segment.
   */
  public acceptPreview(element = this._previewData.element) {
    if (!element) {
      return;
    }

    // Call parent method to handle memo tracking
    super.acceptPreview(element);
  }

  /**
   * Performs interpolation on the active segment index
   */
  public interpolate(element, config) {
    if (!element) {
      return;
    }
    const enabledElement = getEnabledElement(element);

    this._previewData.preview = this.applyActiveStrategyCallback(
      enabledElement,
      this.getOperationData(element),
      StrategyCallbacks.Interpolate,
      config.configuration
    );
    this._previewData.isDrag = true;
  }

  /**
   * Add event handlers for the modify event loop, and prevent default event propagation.
   */
  private _activateDraw = (element: HTMLDivElement): void => {
    element.addEventListener(
      Events.MOUSE_UP,
      this._endCallback as EventListener
    );
    element.addEventListener(
      Events.MOUSE_DRAG,
      this._dragCallback as EventListener
    );
    element.addEventListener(
      Events.MOUSE_CLICK,
      this._endCallback as EventListener
    );
  };

  /**
   * Add event handlers for the modify event loop, and prevent default event prapogation.
   */
  private _deactivateDraw = (element: HTMLDivElement): void => {
    element.removeEventListener(
      Events.MOUSE_UP,
      this._endCallback as EventListener
    );
    element.removeEventListener(
      Events.MOUSE_DRAG,
      this._dragCallback as EventListener
    );
    element.removeEventListener(
      Events.MOUSE_CLICK,
      this._endCallback as EventListener
    );
  };

  public invalidateBrushCursor() {
    if (this._hoverData === undefined) {
      return;
    }
    const { data } = this._hoverData.brushCursor;
    const { viewport } = this._hoverData;

    data.invalidated = true;

    // Todo: figure out if other brush metadata (other than segment color) should get updated
    // during the brush cursor invalidation
    const { segmentColor } = this.getActiveSegmentationData(viewport) || {};
    this._hoverData.brushCursor.metadata.segmentColor = segmentColor;
  }

  renderAnnotation(
    enabledElement: Types.IEnabledElement,
    svgDrawingHelper: SVGDrawingHelper
  ): void {
    if (!this._hoverData || BrushTool.activeCursorTool !== this) {
      return;
    }

    const { viewport } = enabledElement;

    const viewportIdsToRender = this._hoverData.viewportIdsToRender;

    if (!viewportIdsToRender.includes(viewport.id)) {
      return;
    }

    const brushCursor = this._hoverData.brushCursor;

    if (brushCursor.data.invalidated === true) {
      const { centerCanvas } = this._hoverData;
      const { element } = viewport;

      // This can be set true when changing the brush size programmatically
      // whilst the cursor is being rendered.
      this._calculateCursor(element, centerCanvas);
    }

    this.applyActiveStrategyCallback(
      enabledElement,
      this.getOperationData(viewport.element),
      StrategyCallbacks.RenderCursor,
      svgDrawingHelper
    );
  }
}

BrushTool.toolName = 'Brush';
export default BrushTool;
